/**
 * @author Don McCurdy / https://www.donmccurdy.com
 */

import { BufferAttribute, BufferGeometry, FileLoader, Loader } from "../three";

var DRACOLoader = function (manager) {
    Loader.call(this, manager);

    this.decoderPath = "";
    this.decoderConfig = {};
    this.decoderBinary = null;
    this.decoderPending = null;

    this.workerLimit = 4;
    this.workerPool = [];
    this.workerNextTaskID = 1;
    this.workerSourceURL = "";

    this.defaultAttributeIDs = {
        position: "POSITION",
        normal: "NORMAL",
        color: "COLOR",
        uv: "TEX_COORD",
    };
    this.defaultAttributeTypes = {
        position: "Float32Array",
        normal: "Float32Array",
        color: "Float32Array",
        uv: "Float32Array",
    };
};

DRACOLoader.prototype = Object.assign(Object.create(Loader.prototype), {
    constructor: DRACOLoader,

    setDecoderPath: function (path) {
        this.decoderPath = path;

        return this;
    },

    setDecoderConfig: function (config) {
        this.decoderConfig = config;

        return this;
    },

    setWorkerLimit: function (workerLimit) {
        this.workerLimit = workerLimit;

        return this;
    },

    /** @deprecated */
    setVerbosity: function () {
        console.warn(
            "THREE.DRACOLoader: The .setVerbosity() method has been removed."
        );
    },

    /** @deprecated */
    setDrawMode: function () {
        console.warn(
            "THREE.DRACOLoader: The .setDrawMode() method has been removed."
        );
    },

    /** @deprecated */
    setSkipDequantization: function () {
        console.warn(
            "THREE.DRACOLoader: The .setSkipDequantization() method has been removed."
        );
    },

    load: function (url, onLoad, onProgress, onError) {
        var loader = new FileLoader(this.manager);

        loader.setPath(this.path);
        loader.setResponseType("arraybuffer");

        if (this.crossOrigin === "use-credentials") {
            loader.setWithCredentials(true);
        }

        loader.load(
            url,
            (buffer) => {
                var taskConfig = {
                    attributeIDs: this.defaultAttributeIDs,
                    attributeTypes: this.defaultAttributeTypes,
                    useUniqueIDs: false,
                };

                this.decodeGeometry(buffer, taskConfig)
                    .then(onLoad)
                    .catch(onError);
            },
            onProgress,
            onError
        );
    },

    /** @deprecated Kept for backward-compatibility with previous DRACOLoader versions. */
    decodeDracoFile: function (buffer, callback, attributeIDs, attributeTypes) {
        var taskConfig = {
            attributeIDs: attributeIDs || this.defaultAttributeIDs,
            attributeTypes: attributeTypes || this.defaultAttributeTypes,
            useUniqueIDs: !!attributeIDs,
        };

        this.decodeGeometry(buffer, taskConfig).then(callback);
    },

    decodeGeometry: function (buffer, taskConfig) {
        // TODO: For backward-compatibility, support 'attributeTypes' objects containing
        // references (rather than names) to typed array constructors. These must be
        // serialized before sending them to the worker.
        for (var attribute in taskConfig.attributeTypes) {
            var type = taskConfig.attributeTypes[attribute];

            if (type.BYTES_PER_ELEMENT !== undefined) {
                taskConfig.attributeTypes[attribute] = type.name;
            }
        }

        //

        var taskKey = JSON.stringify(taskConfig);

        // Check for an existing task using this buffer. A transferred buffer cannot be transferred
        // again from this thread.
        if (DRACOLoader.taskCache.has(buffer)) {
            var cachedTask = DRACOLoader.taskCache.get(buffer);

            if (cachedTask.key === taskKey) {
                return cachedTask.promise;
            } else if (buffer.byteLength === 0) {
                // Technically, it would be possible to wait for the previous task to complete,
                // transfer the buffer back, and decode again with the second configuration. That
                // is complex, and I don't know of any reason to decode a Draco buffer twice in
                // different ways, so this is left unimplemented.
                throw new Error(
                    "THREE.DRACOLoader: Unable to re-decode a buffer with different " +
                        "settings. Buffer has already been transferred."
                );
            }
        }

        //

        var worker;
        var taskID = this.workerNextTaskID++;
        var taskCost = buffer.byteLength;

        // Obtain a worker and assign a task, and construct a geometry instance
        // when the task completes.
        var geometryPending = this._getWorker(taskID, taskCost)
            .then((_worker) => {
                worker = _worker;

                return new Promise((resolve, reject) => {
                    worker._callbacks[taskID] = { resolve, reject };

                    worker.postMessage(
                        { type: "decode", id: taskID, taskConfig, buffer },
                        [buffer]
                    );

                    // this.debug();
                });
            })
            .then((message) => this._createGeometry(message.geometry));

        // Remove task from the task list.
        geometryPending.finally(() => {
            if (worker && taskID) {
                this._releaseTask(worker, taskID);

                // this.debug();
            }
        });

        // Cache the task result.
        DRACOLoader.taskCache.set(buffer, {
            key: taskKey,
            promise: geometryPending,
        });

        return geometryPending;
    },

    _createGeometry: function (geometryData) {
        var geometry = new BufferGeometry();

        if (geometryData.index) {
            geometry.setIndex(new BufferAttribute(geometryData.index.array, 1));
        }

        for (var i = 0; i < geometryData.attributes.length; i++) {
            var attribute = geometryData.attributes[i];
            var name = attribute.name;
            var array = attribute.array;
            var itemSize = attribute.itemSize;

            geometry.setAttribute(name, new BufferAttribute(array, itemSize));
        }

        return geometry;
    },

    _loadLibrary: function (url, responseType) {
        var loader = new FileLoader(this.manager);
        loader.setPath(this.decoderPath);
        loader.setResponseType(responseType);

        return new Promise((resolve, reject) => {
            loader.load(url, resolve, undefined, reject);
        });
    },

    preload: function () {
        this._initDecoder();

        return this;
    },

    _initDecoder: function () {
        if (this.decoderPending) return this.decoderPending;

        var useJS =
            typeof WebAssembly !== "object" || this.decoderConfig.type === "js";
        var librariesPending = [];

        if (useJS) {
            librariesPending.push(
                this._loadLibrary("draco_decoder.js", "text")
            );
        } else {
            librariesPending.push(
                this._loadLibrary("draco_wasm_wrapper.js", "text")
            );
            librariesPending.push(
                this._loadLibrary("draco_decoder.wasm", "arraybuffer")
            );
        }

        this.decoderPending = Promise.all(librariesPending).then(
            (libraries) => {
                var jsContent = libraries[0];

                if (!useJS) {
                    this.decoderConfig.wasmBinary = libraries[1];
                }

                var fn = DRACOLoader.DRACOWorker.toString();

                var body = [
                    "/* draco decoder */",
                    jsContent,
                    "",
                    "/* worker */",
                    fn.substring(fn.indexOf("{") + 1, fn.lastIndexOf("}")),
                ].join("\n");

                this.workerSourceURL = URL.createObjectURL(new Blob([body]));
            }
        );

        return this.decoderPending;
    },

    _getWorker: function (taskID, taskCost) {
        return this._initDecoder().then(() => {
            if (this.workerPool.length < this.workerLimit) {
                var worker = new Worker(this.workerSourceURL);

                worker._callbacks = {};
                worker._taskCosts = {};
                worker._taskLoad = 0;

                worker.postMessage({
                    type: "init",
                    decoderConfig: this.decoderConfig,
                });

                worker.onmessage = function (e) {
                    var message = e.data;

                    switch (message.type) {
                        case "decode":
                            worker._callbacks[message.id].resolve(message);
                            break;

                        case "error":
                            worker._callbacks[message.id].reject(message);
                            break;

                        default:
                            console.error(
                                'THREE.DRACOLoader: Unexpected message, "' +
                                    message.type +
                                    '"'
                            );
                    }
                };

                this.workerPool.push(worker);
            } else {
                this.workerPool.sort(function (a, b) {
                    return a._taskLoad > b._taskLoad ? -1 : 1;
                });
            }

            var worker = this.workerPool[this.workerPool.length - 1];
            worker._taskCosts[taskID] = taskCost;
            worker._taskLoad += taskCost;
            return worker;
        });
    },

    _releaseTask: function (worker, taskID) {
        worker._taskLoad -= worker._taskCosts[taskID];
        delete worker._callbacks[taskID];
        delete worker._taskCosts[taskID];
    },

    debug: function () {
        console.log(
            "Task load: ",
            this.workerPool.map((worker) => worker._taskLoad)
        );
    },

    dispose: function () {
        for (var i = 0; i < this.workerPool.length; ++i) {
            this.workerPool[i].terminate();
        }

        this.workerPool.length = 0;

        return this;
    },
});

/* WEB WORKER */

DRACOLoader.DRACOWorker = function () {
    var decoderConfig;
    var decoderPending;

    onmessage = function (e) {
        var message = e.data;

        switch (message.type) {
            case "init":
                decoderConfig = message.decoderConfig;
                decoderPending = new Promise(function (resolve /*, reject*/) {
                    decoderConfig.onModuleLoaded = function (draco) {
                        // Module is Promise-like. Wrap before resolving to avoid loop.
                        resolve({ draco: draco });
                    };

                    DracoDecoderModule(decoderConfig);
                });
                break;

            case "decode":
                var buffer = message.buffer;
                var taskConfig = message.taskConfig;
                decoderPending.then((module) => {
                    var draco = module.draco;
                    var decoder = new draco.Decoder();
                    var decoderBuffer = new draco.DecoderBuffer();
                    decoderBuffer.Init(
                        new Int8Array(buffer),
                        buffer.byteLength
                    );

                    try {
                        var geometry = decodeGeometry(
                            draco,
                            decoder,
                            decoderBuffer,
                            taskConfig
                        );

                        var buffers = geometry.attributes.map(
                            (attr) => attr.array.buffer
                        );

                        if (geometry.index)
                            buffers.push(geometry.index.array.buffer);

                        self.postMessage(
                            { type: "decode", id: message.id, geometry },
                            buffers
                        );
                    } catch (error) {
                        console.error(error);

                        self.postMessage({
                            type: "error",
                            id: message.id,
                            error: error.message,
                        });
                    } finally {
                        draco.destroy(decoderBuffer);
                        draco.destroy(decoder);
                    }
                });
                break;
        }
    };

    function decodeGeometry(draco, decoder, decoderBuffer, taskConfig) {
        var attributeIDs = taskConfig.attributeIDs;
        var attributeTypes = taskConfig.attributeTypes;

        var dracoGeometry;
        var decodingStatus;

        var geometryType = decoder.GetEncodedGeometryType(decoderBuffer);

        if (geometryType === draco.TRIANGULAR_MESH) {
            dracoGeometry = new draco.Mesh();
            decodingStatus = decoder.DecodeBufferToMesh(
                decoderBuffer,
                dracoGeometry
            );
        } else if (geometryType === draco.POINT_CLOUD) {
            dracoGeometry = new draco.PointCloud();
            decodingStatus = decoder.DecodeBufferToPointCloud(
                decoderBuffer,
                dracoGeometry
            );
        } else {
            throw new Error("THREE.DRACOLoader: Unexpected geometry type.");
        }

        if (!decodingStatus.ok() || dracoGeometry.ptr === 0) {
            throw new Error(
                "THREE.DRACOLoader: Decoding failed: " +
                    decodingStatus.error_msg()
            );
        }

        var geometry = { index: null, attributes: [] };

        // Gather all vertex attributes.
        for (var attributeName in attributeIDs) {
            var attributeType = self[attributeTypes[attributeName]];

            var attribute;
            var attributeID;

            // A Draco file may be created with default vertex attributes, whose attribute IDs
            // are mapped 1:1 from their semantic name (POSITION, NORMAL, ...). Alternatively,
            // a Draco file may contain a custom set of attributes, identified by known unique
            // IDs. glTF files always do the latter, and `.drc` files typically do the former.
            if (taskConfig.useUniqueIDs) {
                attributeID = attributeIDs[attributeName];
                attribute = decoder.GetAttributeByUniqueId(
                    dracoGeometry,
                    attributeID
                );
            } else {
                attributeID = decoder.GetAttributeId(
                    dracoGeometry,
                    draco[attributeIDs[attributeName]]
                );

                if (attributeID === -1) continue;

                attribute = decoder.GetAttribute(dracoGeometry, attributeID);
            }

            geometry.attributes.push(
                decodeAttribute(
                    draco,
                    decoder,
                    dracoGeometry,
                    attributeName,
                    attributeType,
                    attribute
                )
            );
        }

        // Add index.
        if (geometryType === draco.TRIANGULAR_MESH) {
            // Generate mesh faces.
            var numFaces = dracoGeometry.num_faces();
            var numIndices = numFaces * 3;
            var index = new Uint32Array(numIndices);
            var indexArray = new draco.DracoInt32Array();

            for (var i = 0; i < numFaces; ++i) {
                decoder.GetFaceFromMesh(dracoGeometry, i, indexArray);

                for (var j = 0; j < 3; ++j) {
                    index[i * 3 + j] = indexArray.GetValue(j);
                }
            }

            geometry.index = { array: index, itemSize: 1 };

            draco.destroy(indexArray);
        }

        draco.destroy(dracoGeometry);

        return geometry;
    }

    function decodeAttribute(
        draco,
        decoder,
        dracoGeometry,
        attributeName,
        attributeType,
        attribute
    ) {
        var numComponents = attribute.num_components();
        var numPoints = dracoGeometry.num_points();
        var numValues = numPoints * numComponents;
        var dracoArray;

        var array;

        switch (attributeType) {
            case Float32Array:
                dracoArray = new draco.DracoFloat32Array();
                decoder.GetAttributeFloatForAllPoints(
                    dracoGeometry,
                    attribute,
                    dracoArray
                );
                array = new Float32Array(numValues);
                break;

            case Int8Array:
                dracoArray = new draco.DracoInt8Array();
                decoder.GetAttributeInt8ForAllPoints(
                    dracoGeometry,
                    attribute,
                    dracoArray
                );
                array = new Int8Array(numValues);
                break;

            case Int16Array:
                dracoArray = new draco.DracoInt16Array();
                decoder.GetAttributeInt16ForAllPoints(
                    dracoGeometry,
                    attribute,
                    dracoArray
                );
                array = new Int16Array(numValues);
                break;

            case Int32Array:
                dracoArray = new draco.DracoInt32Array();
                decoder.GetAttributeInt32ForAllPoints(
                    dracoGeometry,
                    attribute,
                    dracoArray
                );
                array = new Int32Array(numValues);
                break;

            case Uint8Array:
                dracoArray = new draco.DracoUInt8Array();
                decoder.GetAttributeUInt8ForAllPoints(
                    dracoGeometry,
                    attribute,
                    dracoArray
                );
                array = new Uint8Array(numValues);
                break;

            case Uint16Array:
                dracoArray = new draco.DracoUInt16Array();
                decoder.GetAttributeUInt16ForAllPoints(
                    dracoGeometry,
                    attribute,
                    dracoArray
                );
                array = new Uint16Array(numValues);
                break;

            case Uint32Array:
                dracoArray = new draco.DracoUInt32Array();
                decoder.GetAttributeUInt32ForAllPoints(
                    dracoGeometry,
                    attribute,
                    dracoArray
                );
                array = new Uint32Array(numValues);
                break;

            default:
                throw new Error(
                    "THREE.DRACOLoader: Unexpected attribute type."
                );
        }

        for (var i = 0; i < numValues; i++) {
            array[i] = dracoArray.GetValue(i);
        }

        draco.destroy(dracoArray);

        return {
            name: attributeName,
            array: array,
            itemSize: numComponents,
        };
    }
};

DRACOLoader.taskCache = new WeakMap();

/** Deprecated static methods */

/** @deprecated */
DRACOLoader.setDecoderPath = function () {
    console.warn(
        "THREE.DRACOLoader: The .setDecoderPath() method has been removed. Use instance methods."
    );
};

/** @deprecated */
DRACOLoader.setDecoderConfig = function () {
    console.warn(
        "THREE.DRACOLoader: The .setDecoderConfig() method has been removed. Use instance methods."
    );
};

/** @deprecated */
DRACOLoader.releaseDecoderModule = function () {
    console.warn(
        "THREE.DRACOLoader: The .releaseDecoderModule() method has been removed. Use instance methods."
    );
};

/** @deprecated */
DRACOLoader.getDecoderModule = function () {
    console.warn(
        "THREE.DRACOLoader: The .getDecoderModule() method has been removed. Use instance methods."
    );
};

export { DRACOLoader };
